查看原文
其他

自研E2E为稳定性保驾护航 | 得物技术

来骏 得物技术
2024-12-05

目录

一、背景

    1. 现状与痛点

二、技术调研

    1. 为什么选择自研一套E2E测试工具,而非传统手动编码方式?

    2.了解Chrome浏览器的Recorder功能

三、E2E介绍

    1. 业界情况

    2. E2E系统的构成

四、架构设计

五、技术实现

    1. 数据收集方案

    2. 执行用例

六、高级推广、协作

七、提升排查、执行效率

八、核心成果

九、总结&展望未来


背景

增长域H5日均访问量巨大,随着业务功能的迭代,互动场景越来越复杂,逻辑组合也愈发复杂。当下,进行全面的自测回归已变得异常艰难。回顾过去四个月,历史故障中多数是由变更引起的。目前前端除了灰度发布和众测,缺乏高效且低成本的回归测试手段,导致无法在开发阶段及早发现问题,使得稳定性压力都集中在变更发布前的验证环节。

现状与痛点

  1. 问题发现后置:系统问题通常在上线后才被发现,导致修复成本增加。

  2. 场景遗漏:大多数用户的账号状态相似(如老用户),可能导致新用户流程等特殊场景被遗漏。测试回归常常只关注常规流程,而对复杂流程及特定账号场景的覆盖不足。

  3. 流程繁琐,成本高:同一流程中有许多分支案例需要回归,涉及数据造数和复杂流程,造成较高的测试成本,而且这些工作往往是重复性的。

  4. 回归验证不确定性:手动验证功能的过程依赖人员的主动性,导致测试结果的不确定性,无法明确确认每个用例是否正常回归。


为提升项目的稳定性,我们经过技术调研与业界解决方案的探讨,决定自研一套端到端(E2E)测试平台。该平台的核心是通过收集用户流、接口数据、存储数据等,使用 Puppeteer 驱动用户流执行,将执行结果与真实行为进行对比,以推断页面是否存在问题。


技术调研

为什么选择自研一套E2E测试工具,

而非传统手动编码方式?

传统的端到端(E2E)测试方法面临诸多挑战。尽管这种方法曾被广泛应用,但随着项目的复杂性和规模的增加,其弊端日益显现。传统E2E测试通常依赖手动编写测试用例代码,而每个用例都需要维护相应的测试代码。例如,若要判断某页面上是否存在类名为“dw-title”的元素,开发人员就需要手动编写如下的测试代码:

const puppeteer = require('puppeteer');
(async () => { // 启动浏览器 const browser = await puppeteer.launch(); const page = await browser.newPage(); // 访问目标网页(替换为你想要访问的URL) await page.goto('https://example.com');
// 使用 page.$() 查询元素 const element = await page.$('.dw-title');
if (element) { console.log('页面中存在类名为 dw-title 的元素。'); } else { console.log('页面中不存在类名为 dw-title 的元素。'); }
// 关闭浏览器 await browser.close();})();

然而,这种方式存在多重挑战:


  1. 开发成本高:每个测试用例都需独立编写和调试,开发人员需在E2E测试中投入大量的时间与精力。随着项目的规模扩大,开发成本逐步上升,可能导致项目进度延迟。

  2. 维护成本高:随着功能的迭代与变更,手动维护测试代码不仅耗时,而且容易出错。每次功能更新都可能需要逐一修改测试用例,增加了维护的复杂性和工作量。

  3. 理解成本高:开发人员在编写E2E测试用例时,必须对页面功能有深入了解。这对于业务不熟悉的新团队成员而言,构成了一种额外学习成本,可能影响团队的工作效率和沟通协作。


了解Chrome浏览器的Recorder功能

Chrome浏览器提供的Recorder功能可以有效地录制、重放并衡量用户交互流。Recorder面板为开发者和测试人员提供了一种简单而直观的录制页面行为的方法,具体细节可以参考官方文档。下图展示了一个基本的页面流录制示例,这些用户操作都可以通过代码形式进行呈现。

尽管Chrome的Recorder功能在执行用例层面提供了一种实用的方法,但仍然存在几个关键问题,限制了其在实际应用中的效果:


不支持移动模式:


在移动模式下,录制和回放的效果并不理想。通过下方的录屏可以明显看到,当设置为移动模式时,录制和回放出现了严重的抖动问题,影响测试结果的可靠性。

缺乏数据层处理:


检查Recorder生成的Puppeteer代码,会发现它仅简单地记录用户的行为,而未能处理数据层。这在许多业务场景中是一个明显的不足之处,例如:


  1. 存储能力:许多应用依赖于Cookie和LocalStorage来存储用户状态。例如,当用户首次访问页面时,可能会展示引导弹框,而该弹框是否已展示的判断逻辑通常依赖于存储在Cookie中的信息。

  2. 确定性与一致性:不同用户访问同一个增长页面时,所处的状态并不完全相同。例如,新手用户需要走过新手流程,而有待兑奖的用户则可能需要处理不同的状态。在执行E2E测试时,需要确保每次执行的接口数据一致性,以保证测试的确认性和可靠性。


综上所述,尽管Chrome的Recorder功能在录制用户行为方面提供了便利,但其在移动模式支持和数据层处理的不足,限制了其在复杂业务场景中的应用。


E2E介绍

端到端(E2E)测试是一种重要的软件测试策略,其目的是从用户的视角对整个应用程序进行验证,确保各个组件的集成和功能在一起正常工作。在软件开发和测试的领域,E2E 测试已经越来越受到重视,尤其是在敏捷开发和持续集成(CI/CD)的背景下。


业界情况

在过去,软件测试主要集中在单元测试和集成测试,往往忽略了系统的整体表现。然而,随着应用变得越来越复杂,尤其是微服务架构、云计算和移动应用的普及,端到端测试逐渐成为不可或缺的一部分。如今,许多主流的自动化测试框架,如 Selenium、Cypress、Puppeteer 和 Playwright,都是为了支持 E2E 测试而创建的。这些工具帮助开发团队模拟用户行为,执行跨浏览器和跨平台的测试,提高了软件交付的效率和质量。


市面对应产品:


  1. Selenium:

    Selenium 是一个广泛使用的开源框架,支持多种编程语言(如 Java、Python、C# 等),可以与不同的浏览器配合使用。其强大的功能和灵活性使其成为许多企业的首选。

  2. Cypress:

    Cypress 是一个现代化的E2E测试框架,专注于速度和易用性。其独特的架构允许开发者在浏览器中直接运行测试,能够快速反馈给开发者,常被用于前端开发。

  3. Puppeteer:

    Puppeteer 是一个为 Chrome 和 Chromium 提供的 Node.js 库,可以用于执行自动化浏览器操作。它适合进行性能测试、截图、PDF 生成等任务,同时也可用于标准的E2E测试。

  4. Playwright:

    由 Microsoft 开发,Playwright 是一个强大的 E2E 测试框架,支持多种浏览器(包括 Chrome、Firefox、Safari 等)。它的跨浏览器支持、网络拦截功能以及强大的 API 使其在现代开发流程中越来越受欢迎。


E2E 系统的构成

一个典型的 E2E 系统通常包括以下几个关键组件:


  1. 测试用例管理:

    这是制定和管理 E2E 测试用例的地方,确保覆盖了所有的用户场景和业务需求。测试用例应该清晰且易于理解,以便开发和测试团队一致执行。

  2. 自动化测试框架:

    使用自动化工具(如 Selenium、Cypress、Puppeteer 等)来执行测试。这些工具可以模拟真实用户的行为,控制浏览器进行一系列的操作。自动化测试框架通常提供清晰的 API,便于编写和维护测试代码。

  3. 持续集成/持续交付(CI/CD)管道:

    E2E 测试通常集成在 CI/CD 流程中,以确保每次代码提交或更新都能够自动执行相关的测试。这样可以及时发现并修复问题,缩短反馈周期。

  4. 测试环境和数据:

    为了执行 E2E 测试,需要准备一个与生产环境相似的测试环境,并且使用相应的测试数据。这样可以确保测试结果的准确性,并能更真实地模拟用户的操作环境。

  5. 结果分析和报告:

    测试执行完成后,自动生成测试结果的报告,概述测试的成功与失败情况。这些报告可以帮助团队快速定位问题,并评估软件的稳定性和质量。

  6. 错误追踪与回归测试:

    在测试过程中,发现的任何错误需要及时记录并追踪修复。同时,定期执行回归测试,确保新功能的引入不会影响到现有功能的正常运行。


通过以上构成部分,E2E 测试能够全面覆盖用户体验,确保整个应用程序在不同场景下的稳定性和可靠性。在数字化和敏捷开发的时代,E2E 测试的价值愈发凸显,提供了更高的交付质量和用户满意度。


架构设计

整体设计如下:

整体设计由数据收集和执行两个核心能力组成。录制主要使用Chrome插件记录用户流、接口数据等,执行时利用puppeteer驱动用户流执行,设计如下:

执行层主要包含项目构建、获取用例、puppeteer驱动用户流等流程,设计如下:


技术实现

数据收集方案

为了有效地收集和分析用户行为数据,采用Chrome插件的形式进行手动数据采集。该插件将主要收集以下信息:


  1. BVT用例转成行为

    根据测试同学提供的BVT测试用例中的行为(包括点击、滑动等操作),在浏览器中进行复原,复原过程中这些操作将以时间戳的形式进行记录,确保能够清晰地记录在特定时刻的行为。

  2. 存储信息

    插件将收集浏览器当前全部的Cookie、LocalStorage和SessionStorage数据。

  3. 接口信息

    劫持页面的接口请求,存储每一个接口的数据,以便后续使用。这一过程中,不会劫持图片、JavaScript和CSS等资源请求,从而减少不必要的数据干扰,确保收集的数据更具针对性和有效性。


此外,除了收集E2E用例执行所需的信息外,插件还会在录制过程中存储截图和录屏信息等。使用流程如下:

Chrome插件的核心功能

Chrome插件的核心功能包括两大重要部分:


  1. 事件监听

    插件能够智能监听页面中所有可交互的事件类型。会记录用户的每一次行为,如点击、滑动等。例如,对于每一个click事件,插件将详细记录以下信息:

    操作时间:用户执行操作的具体时间。

    位置:用户点击的具体位置(坐标)。

    目标元素:被点击或交互的目标元素,例如按钮、链接或图像。

  2. 请求劫持

    插件能够劫持页面中的所有fetchXHR请求

    记录每个请求的发送信息,包括请求的URL、请求方法和请求参数。

    捕获每个请求的响应数据,包括状态码和返回内容。


核心代码示例

基于以上两大核心功能,以下是插件的核心代码实现示例:

class Recorder { // 记录用户流数据 actions = []; // 记录全部请求数据 requests = {};
constructor() { this.listenEveryThing(); this.httpProxy(); }
listenEveryThing(context) { // 监听事件 actionProxy((action) => this.actions.push(action), context); }
httpProxy(context) { // 劫持请求 ajaxProxy(this.requestProxyCallback, context); fetchProxy(this.requestProxyCallback, context); }
requestProxyCallback = (info) => { const pathname = info.url.replace(/http[s]?:\/\/[^/]*?\//g, '').replace(/\?.+/g, '') const cache = this.requests[pathname] || [] cache.push(info) this.requests[pathname] = cache }}

监听事件并记录用户流核心代码:

const actionProxy = (callback, context) => { const eventListener = (event) => { const { type, target } = event; const eventInfo = { action: { x: event.clientX, y: event.clientY, type, time: Date.now() - InitTime, }, target: { className: target.className, tag: target.tagName, children: target.children.length, id: target.id, rect: target.getBoundingClientRect() }, };
callback(eventInfo); };
// 监听所有事件 const eventTypes = [ "touchstart", "touchend", "touchcancel", "touchmove", "mousedown", "mouseup", "mousemove", ];
eventTypes.forEach((type) => { context.addEventListener(type, eventListener, true); });};

劫持fetch请求核心代码:

const fetchProxy = (proxy, context = window) => { const originalFetch = context.fetch;
const newFetch = function (config, options) { return originalFetch(config, options).then(function (response) { // todo... 做一些事情 return response; }); };
context.fetch = newFetch;};

执行用例

Chrome插件录制核心数据结构如下:

const data = { url: '用例录制url', actions: [ { "type": "click", "actions": { "x": 13, "y": 145.484375, "type": "touchstart", }, "target": { "className": "", "tag": "DIV", "children": 2, "id": "", "rect": { "x": 13, "y": 145.484375 } } }, ], requests: { '/demo': { "code": 200, "data": { "list": "录制时接口返回的真实数据" }, } }}

有了数据,接下来就可以通过puppeteer结合数据执行E2E测试用例,首先看一下E2E用例执行和判断情况:

通过录制可以明显看到,弹框打开以后没有被正确关闭,查看用例录制用例时录屏和错误信息。

E2E用例管理平台查看错误信息:

执行结果通过飞书消息推送:

E2E用例管理平台查看录制时视频:

劫持接口

首先是利用puppeteer打开页面,在打开页面前,需要进行一些初始化工作,如设置页面宽度、localStorage等。

class Client { constructor() {}
start(caseInfo) { this.prepareAndGo(caseInfo) } // async prepareAndGo() { const page = await this.browserContext.newPage() this.page = page // 初始化基本信息 await page.setUserAgent('录制时user-agent') await this.setViewport({ width: '录制时宽度', height: '录制时高度' }) // 拦截请求 await this.requestProxy() // 清除本地缓存 await page.evaluate(`localStorage.clear()`) await page.evaluate(`sessionStorage.clear()`)
// 设置缓存 await page.evaluate(() => { // 批量设置 localStorage }, window) // 打开页面 await page.goto(caseInfo?.url) }}
const client = new Client(async () => { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'], })
return browser.defaultBrowserContext()})
// caseInfo 既前文 录制核心数据client.start(caseInfo)

上文介绍提到Chrome插件核心做了两件事情:监听事件、劫持请求,在执行时,核心也是围绕这两件事情,细心的同学发现上面代码中出现的this.requestProxy(),接下来便先来看看其核心实现:

async requestProxy() { const page = this.page; const requests = this.requests; // 启用请求拦截 await page.setRequestInterception(true);
page.on("request", async (request) => { const url = request.url(); const pathname = new URL(url).pathname.slice(1); if (requests[pathname]) { // 说明命中拦截了,走拦截流程 const newRequest = this.requestHandle({ requests: requests[pathname], pathname, currentRequest: { headers: request.headers(), method: request.method(), }, }); if (newRequest !== null) { const responseHeaders = newRequest.responseHeaders.reduce( (pre, [key, value]) => { return { ...pre, [key]: value }; }, {} );
if ( this.currentApiDetectConfig && this.currentApiDetectConfig.api === pathname ) { const response = JSON.parse(newRequest.response); newRequest.response = JSON.stringify(response); }
return request.respond({ status: 200, body: newRequest.response, headers: { ...responseHeaders, }, }); } }
request?.continue(); }); }

上述代码主要做了下面几件事情:


  1. 使用 page.setRequestInterception(true) 来启用请求拦截。

  2. 使用 page.on('request', ...) 方法来监听所有的请求并在内部做接口拦截处理。


监听事件

为了确保每次用例执行时与录制时的数据一致性,需要对请求进行劫持。接下来,将复现用例录制时的操作行为。用例执行的核心可以分为以下两部分:


  1. 模拟用户操作:使用 Puppeteer 根据数据中的动作(actions)来模拟用户的操作行为。

  2. 行为判断:在用例执行过程中,需要对 Puppeteer 的执行结果进行判断,检查其行为与录制时是否一致。若发现执行过程中的行为或结果与录制时不符,页面可能存在功能异常。


模拟用户操作:


在 Puppeteer 中,page.mouse.click(x, y) 方法用于模拟鼠标点击指定坐标 (x, y) 的位置。

(async () => { // 启动浏览器 const browser = await puppeteer.launch(); const page = await browser.newPage(); // 导航到目标网页 await page.goto('xxx');
// 执行鼠标点击操作 const x = 100; // 要点击的 X 坐标 const y = 200; // 要点击的 Y 坐标 await page.mouse.click(x, y);})();

也可以通过类名进行点击:

const titleElement = await page.$('.title'); if (titleElement) { await titleElement.click(); // 点击该元素} else { console.log("未找到类名为 'title' 的元素");}

收集用户流阶段得到的数据:

{ "type": "click", "actions": { "x": 13, "y": 145.484375, "type": "touchstart", }, "target": { "className": "", "tag": "DIV", "children": 2, "id": "", "rect": { "x": 13, "y": 145.484375 } }}

那么想要让用例执行起来就很简单了,只需要遍历actions就可以让用例动起来。

async runActions(actions) { const page = this.page; for (const actionCase of actions) { for (let i = 0; i < actionCase?.actions?.length; i++) { const action = actionCase.actions[i]; const { x, y, time, type } = action; switch (type) { case "click": await page.touchscreen.tap(x, y); break; // ... 省略各种事件处理 } } } }

行为判断:


在用例执行过程中,需要对行为及其结果进行全面判断。例如,如果当前页面上存在一个类名为 open-modal 的按钮,当点击该按钮时,应该能够成功打开一个弹框。如果在执行过程中发现按钮不存在或弹框未能成功打开,这可能表明页面功能存在异常。

为实现上述功能,核心任务是:监听页面事件,同时收集 Puppeteer 执行过程中记录的位置信息和目标元素。通过将执行时收集到的信息与用例录制时的信息进行对比

核心代码:

async injectActionProxy() { const ACTION_PROXY_METHOD_NAME = 'ACTION_PROXY_METHOD_NAME' const ACTION_LISTENER_METHOD_NAME = 'ACTION_LISTENER_METHOD_NAME' const page = this.page
// 注册函数 await page.exposeFunction(ACTION_LISTENER_METHOD_NAME, async (action) => { this.actionIndex++ const currentAction = this.testActions[this.actionIndex] if (currentAction) { // 对比信息并判断 const compareActionRes = await compareAction({ caseTarget: currentAction.target, testTarget: action.target, action: currentAction.type, page, }) this.actionTests.push(compareActionRes) } })
// actionProxy是前文监听事件并记录用户流核心代码 await page.evaluate(`window['${ACTION_PROXY_METHOD_NAME}'] = ${actionProxy.toString()}`) await page.evaluate( `(window['${ACTION_PROXY_METHOD_NAME}'])(${ACTION_LISTENER_METHOD_NAME},document,true)`, ) }

compareAction主要是一些用例执行结果逻辑处理,此处便不再赘述。


高效推广、协作

在E2E推广的初期,需要与测试和前端团队密切协作,这一阶段不可避免地暴露出一些问题和挑战。以下是过程中遇到的关键问题及其优化实践:


  1. 完善使用文档

    尽管初版使用文档已编写完成,但其他团队成员在使用过程中仍然会遇到各种问题。采取以下措施:

  • 基于用户反馈进行迭代:在使用过程中收集用户的反馈和常见问题,并据此不断优化和完善文档内容。建立定期评审机制,确保文档与实际使用场景保持一致。

  • 及时更新和通知:一旦有新功能上线,需在相关团队群内及时通知,并更新文档。

  • 提供示例和案例:为提高文档的实用性,添加一些具体的使用示例和案例,帮助使用者更好地理解如何应用E2E测试。


     2.建立信任

       在初期推广阶段,团队对E2E测试的效果存在疑虑,需要采取有效行动来建立信任:


  • 快速落地和核心用例录制:针对初期的推广,重点选择互动增长签到和许愿树项目等核心用例,迅速落地一个可执行的测试版本。通过有效的测试覆盖,展示E2E测试的价值。

  • 展示成功案例:在关键用例成功运行后,及时分享相关结果和数据,展示其在提前拦截线上问题方面的效果,这将有助于消除其他团队的质疑,增强信任感。

  • 提供支持和培训:为帮助团队成员顺利接入,可以开展培训课程和工作坊,现场解答疑问,并为他们提供持续的技术支持。鼓励他们亲自参与录制工作,从而增强对E2E测试的认同感。


提升排查、执行效率

当用例数量达到一定规模时,每次执行可能需要过长的时间,导致在问题出现时难以快速定位和分析根因。优化方案如下:


  1. 多进程执行

    比如某个项目录制的70条用例,单次执行耗时近30分钟,执行时间过长影响了E2E测试的及时性。我们引入了多进程执行的方法,具体措施包括:


    1. CPU资源利用:通过利用计算机的多核CPU,开启多个无头浏览器并行执行E2E用例,这样可以显著提高流水线的执行速度,预计提升效率约2.5倍。

    2. 结果及时反馈:多进程的实施使得检测问题的响应速度大幅提升,从而增强了测试过程的实用性和时效性。

  2. 问题排查

    用例执行失败时,当前仅能通过消息推送跳转至用例详情,并查看执行时的截图和录像。但是,如果是由于页面布局变化导致的执行失败,通常难以迅速识别问题所在。因此,我们提出了以下改进措施:


    1. 优化问题定位流程:

      1. 咨询录制者:联系录制用例的团队成员,他们有时能提供页面变动的信息。

      2. 用例历史对比:通过之前的用例截图和视频录制进行对比,有助于快速发现变化。

      3. DOM与行为分析:查看页面的DOM结构和用例行为并对比类名,这是效率最低的方法。

    2. 新增录制时的截图与操作录屏:

      1. 引入在录制用例时自动截屏和记录操作视频的功能,以便在执行发生错误时进行快速对比。这能显著提高定位执行错误根因的效率,减少人工逐帧查看对比的低效环节。

核心成果

经过半年多运行,我们确实取得了一些显著效果。


  1. 覆盖范围:目前已接入互动增长、新客及社区团队,累计沉淀多条测试(BVT)用例,执行用例次数达十万+。

  2. 问题拦截:多次成功提前拦截线上问题,改善了问题发现的时机,有效解决了潜在问题,确保上线时不带问题。


通过这套自研的 E2E 测试平台,不仅提高了问题的发现率,还降低了因问题上线所带来的风险,助力业务的持续稳定发展。


总结&展望未来

当前,整体E2E测试平台已成功完成基础功能建设,并覆盖了多条业务核心的基础验收测试(BVT)用例场景。这一成果为业务的稳定性提供了一定的保障。


在未来,结合端到端(E2E)测试平台与GPT、图像识别等新技术,可以展望出以下几个重要的发展方向和应用场景:


  1. 智能化测试用例生成:

    利用GPT模型生成E2E测试用例,能够根据需求文档、用户日志或代码库自动生成相应的测试脚本。这将极大地减少录制测试用例的时间,提高测试的覆盖率和准确性。

  2. 图像识别与UI自动测试:

    通过图像识别技术,E2E测试平台可以自动识别应用界面的不同元素,进行更直观的UI测试。这包括识别按钮、图标、文本等,确保UI的正确性和一致性。

  3. 实时问题识别与修复:

    AI驱动的E2E测试平台可以在测试运行时实时监控应用的性能指标和错误日志,通过机器学习算法分析异常情况。

  4. 智能分析与报告生成:

    当E2E测试完成后,结合AI技术,系统能够自动分析测试结果并生成详细的报告,甚至可以用自然语言描述问题及其影响,提供更易读的分析结果。

  5. 自动化学习与优化:

    未来的E2E测试平台将能够学习历史测试数据,识别常见故障模式,并不断优化测试策略和用例。通过反馈机制,系统将能够自我调整,以提高测试效率和精准度。

  6. 跨设备和环境的验证:

    E2E测试将能够利用AI技术支持跨设备和多环境的自动化测试,确保应用在不同平台(如桌面应用、移动设备、Web等)上的一致性和可靠性。这包括对不同操作系统、浏览器和网络条件的适应性测试。


结合AI、GPT、图像识别等新技术的E2E测试平台将使自动化测试过程更加智能化、高效化和自动化。


往期回顾


1.浅析Java类隔离规避依赖冲突的实现原理|得物技术

2.包材推荐中的算法应用|得物技术

3.深入理解 Babel - 微内核架构与 ECMAScript 标准化|得物技术

4.链路级资损防控之资损字段防控实践|得物技术

5.B端常用交互方式的量化及优化实践和指引|得物技术


文 / 来骏


关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:


继续滑动看下一个
得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存